﻿/**
 * Copyright 2010 Dave Keen
 * http://www.actionscriptdeveloper.co.uk
 * 
 * This file is part of Flextrine.
 * 
 * Flextrine is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * and the Lesser GNU General Public License along with this program.
 * If not, see <http://www.gnu.org/licenses/>.
 * 
 */

package org.davekeen.flextrine.orm {
	import flash.events.Event;
	import flash.events.EventDispatcher;
	import flash.utils.describeType;
	import flash.utils.Dictionary;
	import mx.collections.ArrayCollection;
	import mx.logging.Log;
	import mx.logging.LogEventLevel;
	import mx.logging.targets.TraceTarget;
	import mx.rpc.AsyncToken;
	import mx.rpc.events.ResultEvent;
	import mx.rpc.Responder;
	import mx.utils.ObjectUtil;
	import org.davekeen.delegates.RemoteDelegate;
	import org.davekeen.flextrine.orm.delegates.FlextrineDelegate;
	import org.davekeen.flextrine.orm.events.FlextrineEvent;
	import org.davekeen.flextrine.orm.metadata.MetaTags;
	import org.davekeen.flextrine.orm.rpc.FlextrineAsyncResponder;
	import org.davekeen.flextrine.util.EntityUtil
	import org.davekeen.flextrine.util.ClassUtil;
	import mx.core.mx_internal;
	
	// Required for token.applyResult
	use namespace mx_internal;
	
	/**
	 * ...
	 * @author Dave Keen
	 */
	public class EntityManager extends EventDispatcher {
		
		private static var _instance:EntityManager;
		private static var singletonInstantiation:Boolean;
		
		private var repositories:Dictionary;
		
		private var unitOfWork:UnitOfWork;
		
		private var flextrineDelegate:FlextrineDelegate;
		
		private var configuration:Configuration;
		
		// This object is used to track cyclical references and ensure we don't get into an inifinite loop when adding heirarchical objects to the repositories
		private var visited:Object;
		
		public function EntityManager() {
			// Ensure singleton integrity
			if (!singletonInstantiation)
				throw new Error("EntityManager is a singleton and can only be accessed through getInstance()");
			
			// Configure logging for use within Flextrine
			var logTarget:TraceTarget = new TraceTarget();
			logTarget.filters = [ "org.davekeen.*" ];
			logTarget.level = LogEventLevel.ALL;
			logTarget.includeDate = false;
			logTarget.includeTime = false;
			logTarget.includeCategory = true;
			logTarget.includeLevel = true;
			Log.addTarget(logTarget);

			unitOfWork = new UnitOfWork(this);
			repositories = new Dictionary(true);
			
			// Create the delegate for RPC calls to PHP.  Since other programs can hook into these events we need to give these maximum priority as there
			// are Flextrine specific tasks (e.g. populating the repositories) that need to be done before anything else.  Use a priority of int.MAX_VALUE
			// to achieve this.
			flextrineDelegate = new FlextrineDelegate();
			flextrineDelegate.addEventListener(FlextrineEvent.LOAD_COMPLETE, onFlextrineLoadComplete, false, int.MAX_VALUE, false);
			flextrineDelegate.addEventListener(FlextrineEvent.FLUSH_COMPLETE, onFlextrineFlushComplete, false, int.MAX_VALUE, false);
		}
		
		public static function getInstance():EntityManager {
			if (!_instance) {
				singletonInstantiation = true;
				_instance = new EntityManager();
				singletonInstantiation = false;
			}
			
			return _instance;
		}
		
		public function getUnitOfWork():UnitOfWork {
			return unitOfWork;
		}
		
		/**
		 * @private 
		 * @return
		 */
		internal function getDelegate():FlextrineDelegate {
			return flextrineDelegate;
		}
		
		public function setConfiguration(configuration:Configuration):void {
			this.configuration = configuration;
			
			flextrineDelegate.gateway = configuration.gateway;
			flextrineDelegate.service = configuration.service;
		}
		
		public function getConfiguration():Configuration {
			return configuration;
		}
		
		public function persist(entity:Object):Object {
			// Add the entity to the appropriate repository, and receive a temporary id back
			var temporaryUid:String = (getRepository(ClassUtil.getClass(entity)) as EntityRepository).persistEntity(entity);
			
			// If the entity was already persisted we get null back and do nothing
			if (temporaryUid) {
				// Add the persist operation to the unit of work
				unitOfWork.persist(entity, temporaryUid);
				
				// Return the entity to aid in the chaining methods together
				return entity;
			} else {
				return null;
			}
		}
		
		public function merge(entity:Object):Object {
			unitOfWork.merge(entity);
			
			// Return the entity to aid in the chaining methods together
			return entity;
		}
		
		public function remove(entity:Object):Object {
			if ((getRepository(ClassUtil.getClass(entity)) as EntityRepository).deleteEntity(entity))
				unitOfWork.remove(entity);
			
			// Return the entity to aid in the chaining methods together
			return entity;
		}
		
		public function select(query:String, fetchMode:String = null):AsyncToken {
			return getDelegate().select(query, (fetchMode) ? fetchMode : getConfiguration().fetchMode);
		}
		
		public function selectOne(query:String, fetchMode:String = null):AsyncToken {
			return getDelegate().selectOne(query, (fetchMode) ? fetchMode : getConfiguration().fetchMode);
		}
		
		public function flush(fetchMode:String = null):AsyncToken {
			return unitOfWork.flush((fetchMode) ? fetchMode : getConfiguration().fetchMode);
		}
		
		public function callRemoteMethod(methodName:String, ...args):AsyncToken {
			return getDelegate().callRemoteMethod(methodName, args);
		}
		
		public function requireOne(entity:Object, onResult:Function = null, onFault:Function = null):AsyncToken {
			if (entity is ArrayCollection)
				throw new FlextrineError("requireOne can only be called on a single valued association, not a collection", FlextrineError.ILLEGAL_REQUIRE);
				
			if (EntityUtil.isInitialized(entity)) {
				if (onResult != null) onResult();
				// TODO: This should return an AsyncToken that initializes instantly
				return null;
			} else {
				var asyncToken:AsyncToken = getRepository(ClassUtil.getClass(entity)).loadOneBy(EntityUtil.getIdObject(entity));
				if (onResult != null || onFault != null) asyncToken.addResponder(new FlextrineAsyncResponder(onResult, onFault));
				return asyncToken;
			}
		}
		
		public function requireMany(parentEntity:Object, manyAttributeName:String, onResult:Function = null, onFault:Function = null):AsyncToken {
			if (!(parentEntity[manyAttributeName] is ArrayCollection))
				throw new FlextrineError("requireMany can only be called on a collection", FlextrineError.ILLEGAL_REQUIRE);
			
			if (EntityUtil.isCollectionInitialized(parentEntity, manyAttributeName)) {
				if (onResult != null) onResult();
				// TODO: This should return an AsyncToken that initializes instantly
				return null;
			} else {
				// TODO: Don't think we need this any more
				var targetEntity:String = EntityUtil.getTargetEntityForAssociation(parentEntity, manyAttributeName);
				
				// Build up the fetch query to refetch the parentEntity but fetch joined to the association we are requiring
				var query:String = "SELECT p, a FROM ";
				query += ClassUtil.getQualifiedClassNameAsString(parentEntity).replace(".", "\\") + " p JOIN p." + manyAttributeName + " a ";
				query += "WHERE p.id=" + parentEntity.id;
				
				var asyncToken:AsyncToken = selectOne(query);
				if (onResult != null || onFault != null) asyncToken.addResponder(new FlextrineAsyncResponder(onResult, onFault));
				return asyncToken;
			}
		}
		
		/**
		 * Get the EntityRepository for the given class.  If the repository does not exist then create an empty one and return it.
		 * 
		 * @param	entityClass
		 * @return
		 */
		public function getRepository(entityClass:Class):IEntityRepository {
			if (!repositories[entityClass])
				repositories[entityClass] = new EntityRepository(this, entityClass);
			
			return repositories[entityClass] as IEntityRepository;
		}
		
		/**
		 * This empties the repositories and the unit of work.
		 * 
		 * TODO: This has the potential to cause memory leaks - check this out!
		 */
		public function clear():void {
			for each (var repository:EntityRepository in repositories)
				repository.clear();
			
			unitOfWork = new UnitOfWork(this); // TODO: This should probably call unitOfWork.clear() instead
		}
		
		private function onFlextrineLoadComplete(e:FlextrineEvent):void {
			// If we got nothing back then no changes are required on the server so we don't need to do anything
			if (!e.data) return;
			
			// Convert the result to an array (even if it is a single entity returned from load())
			var entities:Array = (e.data is Array) ? e.data as Array : [ e.data ];
			
			// Clear the cyclical reference tracker
			visited = new Object();
			
			// Loading objects that are already in the repository results in a merge which means that we can't just return e.data to any responders that were
			// added to the original load call.  Instead we need to build up a new result (which will be an array or an array of entities) and replace the
			// result in the AsyncToken with it.
			var result:Object;
			
			// Add everything to the repository filling in result along the way
			if (e.data is Array) {
				result = new Array();
				for each (var entity:Object in entities)
					result.push(addLoadedEntityToRepository(entity));
			} else {
				result = addLoadedEntityToRepository(e.data);
			}
			
			// Re-apply the result to the token.  We need to remove all responders added by Flextrine (FlextrineAsyncResponders) which will only leave
			// user added ones.
			var resultEvent:ResultEvent = e.resultEvent;
			
			for (var flextrineResponderCount:uint = 0; flextrineResponderCount < resultEvent.token.responders.length; flextrineResponderCount++)
				if (!(resultEvent.token.responders[flextrineResponderCount] is FlextrineAsyncResponder))
					break;
			
			resultEvent.token.responders.splice(0, flextrineResponderCount);
			resultEvent.token.applyResult(new ResultEvent(ResultEvent.RESULT, resultEvent.bubbles, resultEvent.cancelable, result, resultEvent.token, resultEvent.message));
		}
		
		/**
		 * This adds an entity to the applicable EntityRepositories by recursively adding the object itself, followed by its [MappedAssociations].
		 * Once this method has run the repositories will have been populated with the entity tree in the given entity allowing find, findBy and
		 * findAll operations to run locally for each repository.  Note that this method tracks cyclical references so we don't end up in an infinite
		 * loop.
		 * 
		 * @param	entity  The entity to add to the repository
		 */
		private function addLoadedEntityToRepository(loadedEntity:Object):Object {
			// Add the entity itself to the appropriate repository
			if (!visited[EntityUtil.getUniqueHash(loadedEntity)]) {
				// Check if this entity already exists in its repository
				var repository:EntityRepository = getRepository(ClassUtil.getClass(loadedEntity)) as EntityRepository;
				var foundEntity:Object = repository.findOneBy(EntityUtil.getIdObject(loadedEntity));
				
				var repoEntity:Object;
				
				if (!foundEntity) {
					// If the entity doesn't already exist then add it
					repoEntity = (getRepository(ClassUtil.getClass(loadedEntity)) as EntityRepository).addEntity(loadedEntity);
				} else {
					// Update the existing entity with the values from the entity that was just loaded
					repoEntity = repository.updateEntity(loadedEntity);
				}
				
				// Add the entity to the list of visited entities to ensure we don't add the same entity twice
				visited[EntityUtil.getUniqueHash(loadedEntity)] = true;
			}
			
			// Go through the associations adding or remapping as appropriate.  If the entity is a lazily loaded stub we don't want to do this.
			if (EntityUtil.isInitialized(loadedEntity)) {
				for each (var associationAttribute:XML in EntityUtil.getAttributesWithTag(loadedEntity, MetaTags.ASSOCIATION)) {
					// Cascade through the owning (mapped) associations calling addLoadedEntityToRepository on each associated entity.
					var value:* = loadedEntity[associationAttribute];
					
					if (value is ArrayCollection) {
						// The association is a collection
						
						// If the repoEntity is a different instance to the loadedEntity (i.e. we found a matching one in the repository) and
						// this collection is initalized in the loadedEntity, we want to totally replace the collection in the repoEntity so
						// delete anything that is there.
						if (loadedEntity !== repoEntity && EntityUtil.isCollectionInitialized(loadedEntity, associationAttribute))
							repoEntity[associationAttribute].removeAll();
						
						var repoEntityRepository:EntityRepository = getRepository(ClassUtil.getClass(repoEntity)) as EntityRepository;
						
						for (var n:uint = 0; n < value.source.length; n++) {
							var associatedObject:Object = value.source[n];
							if (!visited[EntityUtil.getUniqueHash(associatedObject)])
								// If the repoEntity is a different instance to the loadedEntity we are replacing the collection (which was just
								// deleted) so use addItem), otherwise they are the same instances so we don't use add.
								if (loadedEntity !== repoEntity) {
									// Add the result of the recursion to repoEntity[associationAttribute].  We use a method in EntityRepository
									// so that the change listeners can be disabled during the update.
									repoEntityRepository.addEntityToManyAssociation(repoEntity, associationAttribute, addLoadedEntityToRepository(associatedObject));
								} else {
									// Otherwise they are the same instance so we just add it to the repository without adding it to repoEntity
									// (since repoEntity IS loadedEntity so its already there!)
									addLoadedEntityToRepository(associatedObject);
								}
						}
					} else if (value is Object) {
						// The association is single valued
						if (!visited[EntityUtil.getUniqueHash(value)])
							repoEntity[associationAttribute] = addLoadedEntityToRepository(value);
					} else {
						// Don't add a null attribute
					}
				}
			}
			
			// Return the entity we just added to the repository
			return getRepository(ClassUtil.getClass(loadedEntity)).findOneBy(EntityUtil.getIdObject(repoEntity));
		}
		
		private function onFlextrineFlushComplete(e:FlextrineEvent):void {
			// Clear the unit of work
			unitOfWork.clear();
			
			var changeSet:ChangeSet = new ChangeSet(e.data);
			executeChangeSet(changeSet);
		}
		
		private function executeChangeSet(changeSet:ChangeSet):void {
			var oid:String;
			
			// Entity insertions are a special case - we need to match up the returned entity with the id-less entity currently in the repository.  To do this
			// we pass around a temporary identifier generated when we persisted the object.  addPersistedEntity takes care of matching up the entity with the
			// existing entity in the repository and merging it (or creating a new entity if it was not generated on this client).
			for (oid in changeSet.entityInsertions)
				(getRepository(ClassUtil.getClass(changeSet.entityInsertions[oid])) as EntityRepository).addPersistedEntity(changeSet.entityInsertions[oid], changeSet.temporaryUidMap[oid]);
			
			for (oid in changeSet.entityUpdates)
				(getRepository(ClassUtil.getClass(changeSet.entityUpdates[oid])) as EntityRepository).updateEntity(changeSet.entityUpdates[oid]);
			
			// When the deletions changeset is called on the client that actually performed the delete, obviously the entity will no longer be in that
			// repository and deleteEntity will throw an 'entity not found' exception.  Therefore we only delete from the repository if the
			// entity is not in this clients removedEntities map.  If it *is* in the map we just remove it from the map.
			for (oid in changeSet.entityDeletions) {
				if (!unitOfWork.hasRemovedEntity(changeSet.entityDeletions[oid])) {
					(getRepository(ClassUtil.getClass(changeSet.entityDeletions[oid])) as EntityRepository).deleteEntity(changeSet.entityDeletions[oid]);
				} else {
					unitOfWork.removeEntityFromRemoveMap(changeSet.entityDeletions[oid]);
				}
			}
			
			// At the end of a flush we would expect the unit of work to no longer have any expected entity removals in its map
			if (unitOfWork.hasRemovedEntities())
				throw new Error("Unit of work still contains expected removals after a flush");
		}
		
	}

}